DonationToast.tsx 3.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. 'use client';
  2. import { useEffect, useRef, useState, useCallback } from 'react';
  3. import { useSignalRContext } from '@/contexts/signalrProvider';
  4. import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
  5. import { faGift, faXmark } from '@fortawesome/free-solid-svg-icons';
  6. import './donation-toast.scss';
  7. type ToastItem = {
  8. id: number;
  9. content: string;
  10. };
  11. type Props = {
  12. channelSID: string;
  13. };
  14. const AUTO_DISMISS_MS = 6000;
  15. const MAX_VISIBLE = 3;
  16. let nextId = 0;
  17. /**
  18. * 후원/시스템 알림 토스트 (우상단).
  19. *
  20. * - SignalR `ReceiveSystemMessage` 를 수신하여 토스트로 노출
  21. * - 기존 dpot SignalR 채팅에 표시되던 후원 알림 메시지를 대체
  22. * - YouTube iframe 사용 환경에서 dpot 시스템 메시지 노출 채널 역할
  23. *
  24. * 자동 dismiss: AUTO_DISMISS_MS 후 사라짐. 수동 닫기 버튼 제공.
  25. */
  26. export default function DonationToast({ channelSID }: Props)
  27. {
  28. const { chatConnection, chatConnected } = useSignalRContext();
  29. const [toasts, setToasts] = useState<ToastItem[]>([]);
  30. const timersRef = useRef<Map<number, ReturnType<typeof setTimeout>>>(new Map());
  31. const removeToast = useCallback((id: number) => {
  32. setToasts(prev => prev.filter(t => t.id !== id));
  33. const timer = timersRef.current.get(id);
  34. if (timer) {
  35. clearTimeout(timer);
  36. timersRef.current.delete(id);
  37. }
  38. }, []);
  39. useEffect(() => {
  40. if (!chatConnection || !chatConnected) {
  41. return;
  42. }
  43. // 채널 참가 (시스템 메시지 수신을 위해 필수)
  44. chatConnection.invoke('JoinChannel', channelSID).catch((err) => {
  45. console.error('[DonationToast] 채널 참가 실패:', err);
  46. });
  47. const handler = (content: string) => {
  48. const id = ++nextId;
  49. setToasts(prev => {
  50. const next = [...prev, { id, content }];
  51. // 동시 노출 제한: 가장 오래된 것부터 제거
  52. if (next.length > MAX_VISIBLE) {
  53. const removed = next.shift();
  54. if (removed) {
  55. const timer = timersRef.current.get(removed.id);
  56. if (timer) {
  57. clearTimeout(timer);
  58. timersRef.current.delete(removed.id);
  59. }
  60. }
  61. }
  62. return next;
  63. });
  64. const timer = setTimeout(() => {
  65. setToasts(prev => prev.filter(t => t.id !== id));
  66. timersRef.current.delete(id);
  67. }, AUTO_DISMISS_MS);
  68. timersRef.current.set(id, timer);
  69. };
  70. chatConnection.on('ReceiveSystemMessage', handler);
  71. return () => {
  72. chatConnection.off('ReceiveSystemMessage', handler);
  73. if (chatConnection.state === 'Connected') {
  74. chatConnection.invoke('LeaveChannel').catch(() => {});
  75. }
  76. };
  77. }, [chatConnection, chatConnected, channelSID]);
  78. // 컴포넌트 언마운트 시 모든 타이머 정리
  79. useEffect(() => {
  80. const timers = timersRef.current;
  81. return () => {
  82. timers.forEach(timer => clearTimeout(timer));
  83. timers.clear();
  84. };
  85. }, []);
  86. if (toasts.length === 0) {
  87. return null;
  88. }
  89. return (
  90. <div className="donation-toast" role="region" aria-live="polite" aria-label="후원 알림">
  91. {toasts.map(toast => (
  92. <div key={toast.id} className="donation-toast__item">
  93. <span className="donation-toast__icon" aria-hidden="true">
  94. <FontAwesomeIcon icon={faGift} />
  95. </span>
  96. <span className="donation-toast__content">{toast.content}</span>
  97. <button
  98. type="button"
  99. className="donation-toast__close"
  100. onClick={() => removeToast(toast.id)}
  101. aria-label="알림 닫기"
  102. >
  103. <FontAwesomeIcon icon={faXmark} />
  104. </button>
  105. </div>
  106. ))}
  107. </div>
  108. );
  109. }